/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.apps.dashclock;
import com.google.android.apps.dashclock.api.DashClockExtension;
import com.google.android.apps.dashclock.api.ExtensionData;
import com.google.android.apps.dashclock.api.internal.IExtension;
import com.google.android.apps.dashclock.api.internal.IExtensionHost;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.RemoteException;
import android.os.SystemClock;
import android.text.TextUtils;
import android.util.Pair;
import android.util.SparseArray;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import static com.google.android.apps.dashclock.LogUtils.LOGE;
/**
* The primary local-process endpoint that deals with extensions. Instances of this class are in
* charge of maintaining a {@link ServiceConnection} with connected extensions. There should
* only be one instance of this class in the app.
* <p>
* This class is intended to be used as part of a containing service. Make sure to call
* {@link #destroy()} in the service's {@link android.app.Service#onDestroy()}.
*/
public class ExtensionHost {
// TODO: this class badly needs inline docs
private static final String TAG = LogUtils.makeLogTag(ExtensionHost.class);
private static final int CURRENT_EXTENSION_PROTOCOL_VERSION = 1;
/**
* The amount of time to wait after something has changed before recognizing it as an individual
* event. Any changes within this time window will be collapsed, and will further delay the
* handling of the event.
*/
public static final int UPDATE_COLLAPSE_TIME_MILLIS = 500;
private Context mContext;
private Handler mClientThreadHandler = new Handler();
private ExtensionManager mExtensionManager;
private Map<ComponentName, Connection> mExtensionConnections
= new HashMap<ComponentName, Connection>();
private final Set<ComponentName> mExtensionsToUpdateWhenScreenOn = new HashSet<ComponentName>();
private boolean mScreenOnReceiverRegistered = false;
private volatile Looper mAsyncLooper;
private volatile Handler mAsyncHandler;
public ExtensionHost(Service context) {
mContext = context;
mExtensionManager = ExtensionManager.getInstance(context);
mExtensionManager.addOnChangeListener(mChangeListener);
HandlerThread thread = new HandlerThread("ExtensionHost");
thread.start();
mAsyncLooper = thread.getLooper();
mAsyncHandler = new Handler(mAsyncLooper);
mChangeListener.onExtensionsChanged();
mExtensionManager.cleanupExtensions();
}
public void destroy() {
mExtensionManager.removeOnChangeListener(mChangeListener);
if (mScreenOnReceiverRegistered) {
mContext.unregisterReceiver(mScreenOnReceiver);
mScreenOnReceiverRegistered = false;
}
establishAndDestroyConnections(new ArrayList<ComponentName>());
mAsyncLooper.quit();
}
private void establishAndDestroyConnections(List<ComponentName> newExtensionNames) {
// Get the list of active extensions
Set<ComponentName> activeSet = new HashSet<ComponentName>();
activeSet.addAll(newExtensionNames);
// Get the list of connected extensions
Set<ComponentName> connectedSet = new HashSet<ComponentName>();
connectedSet.addAll(mExtensionConnections.keySet());
for (final ComponentName cn : activeSet) {
if (connectedSet.contains(cn)) {
continue;
}
// Bind anything not currently connected (this is the initial connection
// to the now-added extension)
Connection conn = createConnection(cn, false);
if (conn != null) {
mExtensionConnections.put(cn, conn);
}
}
// Remove active items from the connected set, leaving only newly-inactive items
// to be disconnected below.
connectedSet.removeAll(activeSet);
for (ComponentName cn : connectedSet) {
Connection conn = mExtensionConnections.get(cn);
// Unbind the now-disconnected extension
destroyConnection(conn);
mExtensionConnections.remove(cn);
}
}
private Connection createConnection(final ComponentName cn, final boolean isReconnect) {
final Connection conn = new Connection();
conn.componentName = cn;
conn.contentObserver = new ContentObserver(mClientThreadHandler) {
@Override
public void onChange(boolean selfChange) {
execute(conn.componentName,
UPDATE_OPERATIONS.get(DashClockExtension.UPDATE_REASON_CONTENT_CHANGED),
UPDATE_COLLAPSE_TIME_MILLIS,
DashClockExtension.UPDATE_REASON_CONTENT_CHANGED);
}
};
conn.hostInterface = makeHostInterface(conn);
conn.serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
conn.ready = true;
conn.binder = IExtension.Stub.asInterface(iBinder);
// Initialize the service
execute(conn, new Operation() {
@Override
public void run(IExtension extension) throws RemoteException {
// Note that this is protected from ANRs since it runs in the
// AsyncHandler thread. Also, since this is a 'oneway' call,
// when used with remote extensions, this call does not block.
extension.onInitialize(conn.hostInterface, isReconnect);
}
}, 0, null);
if (!isReconnect) {
execute(conn.componentName,
UPDATE_OPERATIONS.get(DashClockExtension.UPDATE_REASON_INITIAL),
0,
null);
}
// Execute operations that were deferred until the service was available.
// TODO: handle service disruptions that occur here
synchronized (conn.deferredOps) {
if (conn.ready) {
Set<Object> processedCollapsedTokens = new HashSet<Object>();
Iterator<Pair<Object, Operation>> it = conn.deferredOps.iterator();
while (it.hasNext()) {
Pair<Object, Operation> op = it.next();
if (op.first != null) {
if (processedCollapsedTokens.contains(op.first)) {
// An operation with this collapse token has already been
// processed; skip this one.
continue;
}
processedCollapsedTokens.add(op.first);
}
execute(conn, op.second, 0, null);
it.remove();
}
}
}
}
@Override
public void onServiceDisconnected(final ComponentName componentName) {
conn.serviceConnection = null;
conn.binder = null;
conn.ready = false;
mClientThreadHandler.post(new Runnable() {
@Override
public void run() {
mExtensionConnections.remove(componentName);
}
});
}
};
try {
if (!mContext.bindService(new Intent().setComponent(cn), conn.serviceConnection,
Context.BIND_AUTO_CREATE)) {
LOGE(TAG, "Error binding to extension " + cn.flattenToShortString());
return null;
}
} catch (SecurityException e) {
LOGE(TAG, "Error binding to extension " + cn.flattenToShortString(), e);
return null;
}
return conn;
}
private IExtensionHost makeHostInterface(final Connection conn) {
return new IExtensionHost.Stub() {
@Override
public void publishUpdate(ExtensionData data) throws RemoteException {
if (data == null) {
data = new ExtensionData();
}
// TODO: this needs to be thread-safe
mExtensionManager.updateExtensionData(conn.componentName, data);
}
@Override
public void addWatchContentUris(String[] contentUris) throws RemoteException {
if (contentUris != null && contentUris.length > 0) {
ContentResolver resolver = mContext.getContentResolver();
for (String uri : contentUris) {
if (TextUtils.isEmpty(uri)) {
continue;
}
resolver.registerContentObserver(Uri.parse(uri), true,
conn.contentObserver);
}
}
}
@Override
public void setUpdateWhenScreenOn(boolean updateWhenScreenOn) throws RemoteException {
synchronized (mExtensionsToUpdateWhenScreenOn) {
if (updateWhenScreenOn) {
if (mExtensionsToUpdateWhenScreenOn.size() == 0) {
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_SCREEN_ON);
mContext.registerReceiver(mScreenOnReceiver, filter);
mScreenOnReceiverRegistered = true;
}
mExtensionsToUpdateWhenScreenOn.add(conn.componentName);
} else {
mExtensionsToUpdateWhenScreenOn.remove(conn.componentName);
if (mExtensionsToUpdateWhenScreenOn.size() == 0) {
mContext.unregisterReceiver(mScreenOnReceiver);
mScreenOnReceiverRegistered = false;
}
}
}
}
};
}
private void destroyConnection(Connection conn) {
if (conn.contentObserver != null) {
mContext.getContentResolver().unregisterContentObserver(conn.contentObserver);
conn.contentObserver = null;
}
conn.binder = null;
mContext.unbindService(conn.serviceConnection);
conn.serviceConnection = null;
}
private ExtensionManager.OnChangeListener mChangeListener
= new ExtensionManager.OnChangeListener() {
@Override
public void onExtensionsChanged() {
establishAndDestroyConnections(mExtensionManager.getActiveExtensionNames());
}
};
private void execute(final Connection conn, final Operation operation,
int collapseDelayMillis, final Object collapseToken) {
final Object collapseTokenForConn;
if (collapseDelayMillis > 0 && collapseToken != null) {
collapseTokenForConn = new Pair<ComponentName, Object>(conn.componentName,
collapseToken);
} else {
collapseTokenForConn = null;
}
final Runnable runnable = new Runnable() {
@Override
public void run() {
try {
if (conn.binder == null) {
throw new RemoteException("Binder is unavailable.");
}
operation.run(conn.binder);
} catch (RemoteException e) {
LOGE(TAG, "Couldn't execute operation; scheduling for retry upon service "
+ "reconnection.", e);
// TODO: exponential backoff for retrying the same operation, or fail after
// n attempts (in case the remote service consistently crashes when
// executing this operation)
synchronized (conn.deferredOps) {
conn.deferredOps.add(new Pair<Object, Operation>(
collapseTokenForConn, operation));
}
}
}
};
if (conn.ready) {
if (collapseTokenForConn != null) {
mAsyncHandler.removeCallbacksAndMessages(collapseTokenForConn);
}
if (collapseDelayMillis > 0) {
mAsyncHandler.postAtTime(runnable, collapseTokenForConn,
SystemClock.uptimeMillis() + collapseDelayMillis);
} else {
mAsyncHandler.post(runnable);
}
} else {
mAsyncHandler.post(new Runnable() {
@Override
public void run() {
synchronized (conn.deferredOps) {
conn.deferredOps.add(new Pair<Object, Operation>(
collapseTokenForConn, operation));
}
}
});
}
}
public void execute(ComponentName cn, Operation operation,
int collapseDelayMillis, final Object collapseToken) {
Connection conn = mExtensionConnections.get(cn);
if (conn == null) {
conn = createConnection(cn, true);
if (conn != null) {
mExtensionConnections.put(cn, conn);
} else {
LOGE(TAG, "Couldn't connect to extension to perform operation; operation "
+ "canceled.");
return;
}
}
execute(conn, operation, collapseDelayMillis, collapseToken);
}
private final BroadcastReceiver mScreenOnReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
synchronized (mExtensionsToUpdateWhenScreenOn) {
for (ComponentName cn : mExtensionsToUpdateWhenScreenOn) {
execute(cn, UPDATE_OPERATIONS.get(DashClockExtension.UPDATE_REASON_SCREEN_ON),
0, null);
}
}
}
};
static final SparseArray<Operation> UPDATE_OPERATIONS = new SparseArray<Operation>();
static {
_createUpdateOperation(DashClockExtension.UPDATE_REASON_UNKNOWN);
_createUpdateOperation(DashClockExtension.UPDATE_REASON_INITIAL);
_createUpdateOperation(DashClockExtension.UPDATE_REASON_PERIODIC);
_createUpdateOperation(DashClockExtension.UPDATE_REASON_SETTINGS_CHANGED);
_createUpdateOperation(DashClockExtension.UPDATE_REASON_CONTENT_CHANGED);
_createUpdateOperation(DashClockExtension.UPDATE_REASON_SCREEN_ON);
}
private static void _createUpdateOperation(final int reason) {
UPDATE_OPERATIONS.put(reason, new ExtensionHost.Operation() {
@Override
public void run(IExtension extension) throws RemoteException {
// Note that this is protected from ANRs since it runs in the AsyncHandler thread.
// Also, since this is a 'oneway' call, when used with remote extensions, this call
// does not block.
extension.onUpdate(reason);
}
});
}
public static boolean supportsProtocolVersion(int protocolVersion) {
return protocolVersion > 0 && protocolVersion <= CURRENT_EXTENSION_PROTOCOL_VERSION;
}
/**
* Will be run on a worker thread.
*/
public static interface Operation {
void run(IExtension extension) throws RemoteException;
}
private static class Connection {
boolean ready = false;
ComponentName componentName;
ServiceConnection serviceConnection;
IExtension binder;
IExtensionHost hostInterface;
ContentObserver contentObserver;
/**
* Only access on the async thread. The pair is (collapse token, operation)
*/
final Queue<Pair<Object, Operation>> deferredOps
= new LinkedList<Pair<Object, Operation>>();
}
}